Skip to content

Conversation

@TatevikGr
Copy link
Contributor

@TatevikGr TatevikGr commented Jan 30, 2026

Summary by CodeRabbit

  • New Features

    • Message forwarding: send campaigns to friends (optional personal note), per-user limits, admin notifications on success/failure, and forwarding statistics; forwarded messages prefixed "Fwd".
    • Admin-copy emails: configurable toggle to send admin copies and select recipients.
  • Configuration

    • Toggleable SSL verification for external downloads, new forwarding and avatar/upload limits, and forwarding timing/count settings.
  • Internationalization

    • Added translation strings for forwarding flows.

Thanks for contributing to phpList!

@coderabbitai
Copy link

coderabbitai bot commented Jan 30, 2026

📝 Walkthrough

Walkthrough

Adds message-forwarding end-to-end: new forwarding services, guard/limits, builders, DTOs, repository/manager persistence, admin notifications, forwarded-placeholder handling, DI/config updates, many tests and translation entries.

Changes

Cohort / File(s) Summary
Configuration
config/parameters.yml.dist
Added phplist.* params (forwarding, verify_ssl, send_list_admin_copy, max_avatar_size) and changed REST_API_DOMAIN default.
Service wiring
config/services/builders.yml, config/services/messenger.yml
Renamed builder service IDs to FQCNs; removed $mailConstructor DI arg and removed builder injections from messenger handler.
Core builders & headers
src/Domain/Messaging/Service/Builder/BaseEmailBuilder.php, src/Domain/Messaging/Service/Builder/EmailBuilder.php, src/Domain/Messaging/Service/Builder/SystemEmailBuilder.php
Refactored base/header handling, made BaseEmailBuilder abstract, replaced buildPhplistEmail→buildCampaignEmail, switched to concrete content builders, added addBaseCampaignHeaders and propagated forwarding flag through content/attachment flows.
Forwarding builders & stamp
src/Domain/Messaging/Service/Builder/ForwardEmailBuilder.php, src/Domain/Messaging/Service/Builder/HttpReceivedStampBuilder.php
Added ForwardEmailBuilder for friend emails and HttpReceivedStampBuilder to generate Received headers from HTTP request metadata.
Content builders & interface removal
src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php, src/Domain/Messaging/Service/Constructor/SystemMailContentBuilder.php, src/Domain/Messaging/Service/Constructor/MailContentBuilderInterface.php
Removed MailContentBuilderInterface; CampaignMailContentBuilder now accepts forwardedBy and personal note and ensures note injection; SystemMailContentBuilder signature narrowed.
Forward orchestration
src/Domain/Messaging/Service/MessageForwardService.php, src/Domain/Messaging/Service/ForwardingGuard.php, src/Domain/Messaging/Service/ForwardingStatsService.php, src/Domain/Messaging/Service/ForwardContentService.php, src/Domain/Messaging/Service/ForwardDeliveryService.php
New services implement forward flow: permission/limit checks, content retrieval, delivery, per-request friend-count caching, and marking/persisting forward records.
Persistence & DTOs
src/Domain/Messaging/Model/Dto/MessageForwardDto.php, src/Domain/Messaging/Service/Manager/UserMessageForwardManager.php, src/Domain/Messaging/Repository/UserMessageForwardRepository.php
Added MessageForwardDto, manager to create UserMessageForward, and repo methods to count/find forwards.
Admin notifications
src/Domain/Identity/Service/AdminCopyEmailSender.php, src/Domain/Identity/Service/AdminNotifier.php
Added admin-copy sender and notifier for forward success/failure with recipient resolution logic.
Placeholder/context & resolvers
src/Domain/Configuration/Model/Dto/PlaceholderContext.php, src/Domain/Configuration/Service/MessagePlaceholderProcessor.php, src/Domain/Configuration/Service/Placeholder/*
PlaceholderContext.forwardedBy now holds Subscriber; MessagePlaceholderProcessor accepts Subscriber forwardedBy and keepForwardedAttributes flag; many placeholder resolvers use uid "forwarded" when forwarded and adjust footer behavior.
Attachment & analytics
src/Domain/Messaging/Service/AttachmentAdder.php, src/Domain/Analytics/Service/LinkTrackService.php
AttachmentAdder gained $forwarded flag and uses 'forwarded' UID for view URLs when applicable; LinkTrackService adds a comment about forwarded identifiers.
Subscriber attributes & manager
src/Domain/Subscription/... (SubscriberAttributeDefinition.php, SubscriberAttributeValueRepository.php, SubscriberAttributeManager.php, AttributeNotAllowedException.php)
Added toArray(), attribute lookup by name, createOrUpdateByName, constructor promotion refactor, and AttributeNotAllowedException.
Exceptions & config provider
src/Domain/Messaging/Exception/*.php, src/Domain/Configuration/Model/ConfigOption.php, src/Domain/Configuration/Service/Provider/ConfigProvider.php
Added ForwardLimitExceededException and MessageNotReceivedException; new ConfigOption cases (SendAdminCopies, etc.) and boolean parsing changes in ConfigProvider.
External misc & versioning
src/Domain/Common/ExternalImageService.php, src/Core/Version.php, composer.json
Made SSL verification configurable (Autowire), added Version class, and added phpdocumentor/reflection-docblock dependency.
Translations
resources/translations/messages.en.xlf
Removed a memory-related unit; added forward-related translation units (Fwd, Message Forwarded, forward success/failure, test marker).
Message handler & tests
src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php, tests/...
Updated handler to call buildCampaignEmail; updated tests for renamed builders, forwardedBy type change, DI changes, and many new tests covering forwarding features.

Sequence Diagram(s)

sequenceDiagram
    actor User as Forwarding Subscriber
    participant MessageForwardService
    participant ForwardingGuard
    participant MessagePrecacheService
    participant ForwardContentService
    participant ForwardEmailBuilder
    participant ForwardDeliveryService
    participant AdminNotifier
    participant DB as Database

    User->>MessageForwardService: forward(MessageForwardDto)
    MessageForwardService->>ForwardingGuard: assertCanForward(uid, campaign, cutoff)
    ForwardingGuard->>DB: lookup subscriber / received message / count forwards
    DB-->>ForwardingGuard: subscriber/message/counts
    ForwardingGuard-->>MessageForwardService: allowed subscriber

    loop per recipient
        MessageForwardService->>ForwardingGuard: hasAlreadyBeenSent(email, campaign)?
        ForwardingGuard->>DB: check forward records
        DB-->>ForwardingGuard: exists?
        alt not sent
            MessageForwardService->>MessagePrecacheService: precache message
            MessagePrecacheService-->>MessageForwardService: precached data
            MessageForwardService->>ForwardContentService: getContents(campaign, subscriber, email, DTO)
            ForwardContentService->>ForwardEmailBuilder: buildForwardEmail(...)
            ForwardEmailBuilder-->>ForwardContentService: Email + OutputFormat
            ForwardContentService-->>MessageForwardService: Email
            MessageForwardService->>ForwardDeliveryService: send(Email)
            ForwardDeliveryService->>DB: persist/send record
            ForwardDeliveryService-->>MessageForwardService: ack
            MessageForwardService->>AdminNotifier: notifyForwardSucceeded(...)
        else already sent
            MessageForwardService-->>MessageForwardService: skip
        end
    end

    MessageForwardService->>DB: update forwarding subscriber friend count
    DB-->>MessageForwardService: ack
    MessageForwardService-->>User: complete
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/email forwarding' directly aligns with the PR's core objective of implementing email forwarding functionality, as evidenced by the extensive changes across forwarding-related services, builders, and tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/email-forwarding

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot]

This comment was marked as resolved.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@composer.json`:
- Around line 87-88: The composer.json lists "phpdocumentor/reflection-docblock"
at version ^5.2 but no runtime usage was found; either remove that dependency
from the "require" block or move it to "require-dev" if it is only used for
tooling (e.g., PHPStan or docs), and if you intend to keep it update the version
constraint to the current stable "6.0.1"; modify the composer.json entry
accordingly and run composer update to ensure lockfile consistency.
🧹 Nitpick comments (1)
src/Domain/Common/ExternalImageService.php (1)

150-166: Minor inconsistency: file_get_contents fallback doesn't respect $verifySsl.

The cURL path now respects the verifySsl config, but the file_get_contents fallback still uses PHP's default SSL behavior. If someone sets verify_ssl=false expecting to bypass verification, this fallback path would still verify (or vice versa depending on PHP config).

Not a blocker, but worth considering for consistency if both paths should behave the same way.

🔧 Optional: Add SSL context to file_get_contents
 private function downloadUsingFileGetContent(string $filename): string
 {
     $remoteURLContext = stream_context_create([
         'http' => [
             'method' => 'GET',
             'timeout' => $this->externalImageTimeout,
             'max_redirects' => '10',
-        ]
+        ],
+        'ssl' => [
+            'verify_peer' => $this->verifySsl,
+            'verify_peer_name' => $this->verifySsl,
+        ],
     ]);

     $cacheFileContent = file_get_contents($filename, false, $remoteURLContext);

coderabbitai[bot]

This comment was marked as resolved.

@TatevikGr TatevikGr force-pushed the feat/email-forwarding branch from 215071b to a736ce2 Compare February 2, 2026 07:39
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php`:
- Around line 32-33: The injected config key name is misleading: change the
Autowire attribute on the CampaignMailContentBuilder constructor parameter
forwardPersonalNote from '%phplist.forward_personal_note_size%' to a
boolean-named key like '%phplist.forward_personal_note_enabled%'; update any
config definitions, fixtures or tests that set forward_personal_note_size to use
forward_personal_note_enabled, and keep the parameter type (?bool
$forwardPersonalNote = false) and its usage in the builder (where it gates
inclusion of the forwarded note) unchanged so the name reflects boolean
semantics.

In `@src/Domain/Messaging/Service/ForwardingStatsService.php`:
- Around line 40-60: Both incrementFriendsCount and updateFriendsCount must
guard against null subscriber IDs to avoid cache collisions for transient
entities: in incrementFriendsCount and updateFriendsCount, after calling
Subscriber::getId() store it in $subscriberId and immediately return if
$subscriberId === null; this prevents comparing null to
$this->friendCountSubscriberId and stops reusing $this->friendCount for
different transient subscribers, preserving correct behavior of the
$this->friendCount cache and related logic.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php`:
- Around line 26-28: The early return when $ctx->forwardedBy() === null accesses
$ctx->messagePrecacheDto->textFooter / ->htmlFooter without a null check; update
the condition to also ensure $ctx->messagePrecacheDto is truthy (e.g., change
the if to check both $ctx->forwardedBy() === null && $ctx->messagePrecacheDto)
so the ternary that uses $ctx->messagePrecacheDto->textFooter / ->htmlFooter is
only executed when that DTO exists (look for this check pattern used later in
the file and apply the same guard in FooterValueResolver around the existing
isText() ternary).

In `@src/Domain/Identity/Service/AdminCopyEmailSender.php`:
- Around line 57-62: The mapping over $lists in AdminCopyEmailSender (inside the
$this->sendListAdminCopy branch) calls $list->getOwner()->getEmail() without
guarding for a null owner; change the logic to first filter or skip
SubscriberList instances where $list->getOwner() is null and then map only those
with a non-null owner to their emails, ensuring the resulting $emails is an
array (possibly empty) of strings; update the mapping around $lists and
references to getOwner()/getEmail() accordingly so no direct method call occurs
on a null owner.
🧹 Nitpick comments (3)
src/Domain/Messaging/Service/ForwardDeliveryService.php (1)

39-57: Consider using constants or an enum for status values.

The magic strings 'sent' and 'failed' are used here and likely referenced elsewhere. Extracting them to constants (or a PHP 8.1+ enum) would improve type safety and make refactoring easier down the road.

💡 Example using class constants
 class ForwardDeliveryService
 {
+    public const STATUS_SENT = 'sent';
+    public const STATUS_FAILED = 'failed';
+
     public function __construct(
         // ...
     }

     public function markSent(Message $campaign, Subscriber $subscriber, string $friendEmail): void
     {
         $this->messageForwardManager->create(
             subscriber: $subscriber,
             campaign: $campaign,
             friendEmail: $friendEmail,
-            status: 'sent'
+            status: self::STATUS_SENT
         );
     }

     public function markFailed(Message $campaign, Subscriber $subscriber, string $friendEmail): void
     {
         $this->messageForwardManager->create(
             subscriber: $subscriber,
             campaign: $campaign,
             friendEmail: $friendEmail,
-            status: 'failed'
+            status: self::STATUS_FAILED
         );
     }
 }
src/Domain/Messaging/Service/ForwardingGuard.php (1)

47-52: Consider extracting status string to a constant.

The 'sent' status string appears here and likely in other places. A shared constant (or enum) would prevent typos and make the status values discoverable.

Optional improvement
+    private const STATUS_SENT = 'sent';
+
     public function hasAlreadyBeenSent(string $friendEmail, Message $campaign): bool
     {
         $existing = $this->forwardRepository->findByEmailAndMessage($friendEmail, $campaign->getId());

-        return $existing !== null && $existing->getStatus() === 'sent';
+        return $existing !== null && $existing->getStatus() === self::STATUS_SENT;
     }
src/Domain/Messaging/Service/ForwardContentService.php (1)

40-40: TODO noted - link handling for forwards.

Worth tracking this as an issue so it doesn't get lost. Forwarded messages might need special treatment for click tracking or unsubscribe links.

Would you like me to open an issue to track the link handling investigation for forwarded messages?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@tests/Unit/Domain/Messaging/Service/ForwardContentServiceTest.php`:
- Around line 59-66: The test passes the constructor args for MessageForwardDto
in the wrong order: swap the fourth and fifth arguments so they match the
constructor signature (fourth = fromName, fifth = fromEmail); update the
forwardDto instantiation in ForwardContentServiceTest (the new
MessageForwardDto(...) call currently using 'from@example.com' and 'From') to
use 'From' as the fromName and 'from@example.com' as the fromEmail to match
MessageForwardDto::__construct.
🧹 Nitpick comments (8)
tests/Unit/Domain/Messaging/Service/ForwardDeliveryServiceTest.php (1)

67-113: Consider extracting service instantiation to reduce duplication.

The ForwardDeliveryService instantiation is repeated identically in all four test methods. You could DRY this up with a helper method or by creating the service in setUp(). Not a big deal for a small test class, but it'd make future maintenance a bit easier.

♻️ Optional: Extract to setUp or helper
 class ForwardDeliveryServiceTest extends TestCase
 {
     private MailerInterface&MockObject $mailer;
     private UserMessageForwardManager&MockObject $forwardManager;
+    private ForwardDeliveryService $service;

     protected function setUp(): void
     {
         $this->mailer = $this->createMock(MailerInterface::class);
         $this->forwardManager = $this->createMock(UserMessageForwardManager::class);
+        $this->service = new ForwardDeliveryService(
+            mailer: $this->mailer,
+            messageForwardManager: $this->forwardManager,
+            bounceEmail: 'bounce@example.test',
+        );
     }

     public function testSendUsesBounceEnvelopeAndRecipient(): void
     {
-        $service = new ForwardDeliveryService(
-            mailer: $this->mailer,
-            messageForwardManager: $this->forwardManager,
-            bounceEmail: 'bounce@example.test',
-        );
-
         $email = (new Email())->to('friend@example.test');
         // ... rest of test using $this->service
src/Domain/Messaging/Service/Constructor/CampaignMailContentBuilder.php (1)

174-187: The * 2 multiplier on line 181 is unclear.

The condition ($this->forwardPersonalNoteSize * 2) > strlen($note) feels like magic. Why multiply by 2? A comment referencing the ticket (0011996) exists, but the logic itself could use a brief inline explanation for future maintainers.

Also, strlen($note) counts bytes, not characters. For multi-byte (UTF-8) notes, you might want mb_strlen($note, 'UTF-8') if the intent is to limit by character count rather than byte length.

💡 Suggested clarification
         $escapedNote = htmlspecialchars($note, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
-        //0011996: forward to friend - personal message
-        if (($this->forwardPersonalNoteSize * 2) > strlen($note)) {
+        // 0011996: forward to friend - personal message
+        // Allow notes up to twice the configured max size (legacy behavior).
+        $maxAllowedLength = $this->forwardPersonalNoteSize * 2;
+        if ($maxAllowedLength > mb_strlen($note, 'UTF-8')) {
             $htmlMessage = nl2br($escapedNote) . '<br/>' . $htmlMessage;
             $textMessage = $note . "\n" . $textMessage;
         }
tests/Unit/Domain/Messaging/Service/ForwardingGuardTest.php (1)

105-139: Good coverage for hasAlreadyBeenSent scenarios.

Both the true case (existing forward with 'sent' status) and false case (no existing forward) are tested. The mock setup correctly verifies the interaction with findByEmailAndMessage.

One small note: you might also want a test case for when a forward exists but with a non-'sent' status (e.g., 'failed') to ensure that returns false. Currently only the "none exists" case is tested for the false path.

tests/Unit/Domain/Messaging/Service/MessageForwardServiceTest.php (1)

190-194: The logicalOr constraint may not fully validate both formats are used.

The constraint self::logicalOr(OutputFormat::Html, OutputFormat::Text) verifies that each call receives either Html or Text, but doesn't guarantee that both formats are actually used across the two calls. If both calls happened to use OutputFormat::Html, this test would still pass.

If you want stricter verification that both formats are used exactly once, consider using withConsecutive (deprecated in PHPUnit 10+) or capturing the arguments for manual assertion.

That said, this is a minor point since the mock setup at lines 182-184 already controls which format is returned for each call, so the test is still meaningful.

tests/Unit/Domain/Messaging/Service/Builder/HttpReceivedStampBuilderTest.php (1)

55-72: Keep this test hermetic—avoid real reverse DNS lookups.
Line 59 relies on gethostbyaddr() behavior for a TEST‑NET IP. That can still hit resolvers and be flaky/slow in CI. Consider stubbing DNS resolution or injecting a resolver so you can deterministically force the “no reverse DNS” path.

💡 Example: stub gethostbyaddr in the builder namespace (test-only)
<?php
declare(strict_types=1);

namespace PhpList\Core\Domain\Messaging\Service\Builder {
    function gethostbyaddr(string $ip): string|false
    {
        return false; // force fallback to IP-only
    }
}

namespace PhpList\Core\Tests\Unit\Domain\Messaging\Service\Builder;
tests/Unit/Domain/Messaging/Service/ForwardingStatsServiceTest.php (1)

82-85: Mock expectation set after invocation won't work as intended.

The expects(self::once()) on line 70 already ensures only one call happens throughout the test, so the test does verify the reset behavior. However, setting expects(self::never()) on line 83 after the invocation on line 80 is misleading - PHPUnit expectations should be configured before the calls occur.

The test passes for the right reasons (the once() constraint), but this code block suggests a verification that isn't actually happening. Consider removing lines 82-85 or restructuring for clarity.

🧹 Suggested cleanup
         $service->incrementFriendsCount($subscriber);
         $service->incrementFriendsCount($subscriber);
         $service->updateFriendsCount($subscriber);

-        // Second update attempt should be a no-op due to cache reset
-        $this->attrManager->expects(self::never())->method('createOrUpdateByName');
+        // Second update is a no-op (cache was reset) - verified by the once() expectation above
         $service->updateFriendsCount($subscriber);
-        self::assertTrue(true);
     }
tests/Unit/Domain/Identity/Service/AdminNotifierTest.php (1)

51-68: Same withConsecutive deprecation note applies here.

Just like in the other test file, withConsecutive() is deprecated in PHPUnit 10+. Consider refactoring to use a callback with invocation tracking if you upgrade.

tests/Unit/Domain/Identity/Service/AdminCopyEmailSenderTest.php (1)

113-119: withConsecutive will break in PHPUnit 10+ (already deprecated since 9.6).

Heads up — withConsecutive() was deprecated in PHPUnit 9.6 and fully removed in PHPUnit 10. The project is currently on 9.5, so it's fine now, but if you upgrade PHPUnit to 10 or later, this will stop working. When you get there, refactor to use a callback with invocation tracking (similar to your existing send method expectations).

No urgency, but worth flagging for a future PHPUnit upgrade.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants